Documentation

inbox/Authentication Quick Start.md

Authentication Quick Start

1-Line Integration ✨

Adding authentication to any Acsis component is now a single line of code!

Step 1: Add Authentication (1 line!)

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddAcsisDbContext<YourDb>("your-schema");

// 🔐 ADD AUTHENTICATION
builder.AddAcsisAuthentication();

var app = builder.Build();

// Authentication/authorization middleware is auto-included in MapAcsisEndpoints!
app.MapAcsisEndpoints(YourApi.MapYourEndpoints);

app.Run();

Step 2: Protect Endpoints (1 line!)

public static class YourApi
{
    public static void MapYourEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("/items")
            .WithTags("Items")
            .RequireAuthorization(); // 🔐 Entire group protected!

        group.MapGet("/", GetAllHandler);
        group.MapPost("/", CreateHandler);
    }
}

Step 3: Use Helper Extensions

private static async Task<Ok<Item>> CreateHandler(
    [FromBody] CreateItemRequest request,
    ClaimsPrincipal user,
    ItemDataProvider dataProvider
)
{
    // Extract user info with clean helpers
    var userId = user.GetUserId();
    var tenantId = user.GetTenantId();
    var username = user.GetUsername();
    var isAdmin = user.IsAdmin();

    // Or get everything at once
    var audit = user.GetAuditInfo();

    var item = new Item
    {
        Name = request.Name,
        CreatedBy = audit.UserId,
        CreatedAt = audit.Timestamp,
        TenantId = audit.TenantId
    };

    await dataProvider.CreateItem(item);
    return TypedResults.Ok(item);
}

Complete Example: Catalog Component

Before (20+ lines of boilerplate):

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddAcsisDbContext<CatalogDb>("catalog");

// Manual JWT configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var publicKey = builder.Configuration["JWT:PublicKey"];
        using var rsa = RSA.Create();
        rsa.ImportFromPem(publicKey);
        var securityKey = new RsaSecurityKey(rsa.ExportParameters(false));

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = "acsis-identity",
            ValidateAudience = true,
            ValidAudience = "acsis-api",
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = securityKey,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapAcsisEndpoints(ItemApi.MapItemEndpoints);
app.Run();

After (1 line!):

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddAcsisDbContext<CatalogDb>("catalog");

builder.AddAcsisAuthentication(); // 🎉 That's it!

var app = builder.Build();

app.MapAcsisEndpoints(ItemApi.MapItemEndpoints);
app.Run();

Available Helper Extensions

ClaimsPrincipal Extensions

// Get user ID
Guid? userId = user.GetUserId();

// Get tenant ID (multi-tenancy)
Guid? tenantId = user.GetTenantId();

// Get username
string? username = user.GetUsername();

// Check if admin
bool isAdmin = user.IsAdmin();

// Get complete audit info
AuditInfo audit = user.GetAuditInfo();
// Properties: UserId, Username, TenantId, Timestamp

Query Extensions (Multi-Tenancy)

// Automatic tenant filtering
public async Task<List<Item>> GetAllItems(ClaimsPrincipal user)
{
    return await _db.Items
        .FilterByTenant(user) // ✨ Magic!
        .ToListAsync();
}

// Your entity must implement ITenantScoped:
public class Item : ITenantScoped
{
    public long Id { get; set; }
    public string Name { get; set; }
    public Guid? TenantId { get; set; } // Required by ITenantScoped
}

Pre-Configured Authorization Policies

// Available policies (no configuration needed!):
endpoints.MapPost("/items", CreateHandler)
    .RequireAuthorization("CanManageItems");

endpoints.MapDelete("/items/{id}", DeleteHandler)
    .RequireAuthorization("CanDeleteItems");

endpoints.MapPost("/users", CreateUserHandler)
    .RequireAuthorization("CanAdministerUsers");

endpoints.MapGet("/admin/settings", GetSettingsHandler)
    .RequireAuthorization("SystemAdministration");

endpoints.MapGet("/tenant-data", GetTenantDataHandler)
    .RequireAuthorization("RequireTenant");

Policy Details:

Policy Required Roles
CanManageItems AdvancedUser, Supervisor, SystemAdmin, SuperUser
CanDeleteItems SystemAdmin, SuperUser
CanAdministerUsers UserAdministrator, SystemAdmin, SuperUser
SystemAdministration SystemAdmin, SuperUser
RequireTenant Any authenticated user with a tenant_id claim

Advanced Configuration

Custom Configuration

builder.AddAcsisAuthentication(options =>
{
    options.ValidIssuer = "custom-issuer";
    options.ValidAudience = "custom-audience";
    options.ClockSkew = TimeSpan.FromMinutes(10);
    options.RsaPublicKey = "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----";
});

Configuration from appsettings.json

{
  "Authentication": {
    "Acsis": {
      "ValidIssuer": "acsis-identity",
      "ValidAudience": "acsis-api",
      "RsaPublicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
      "ClockSkew": "00:05:00"
    }
  }
}

Then just call:

builder.AddAcsisAuthentication(); // Reads from config automatically!

Service Discovery (Default)

If no configuration is provided, AddAcsisAuthentication() automatically uses service discovery to find the identity service:

// In development:
// - Authority: http://identity (via Aspire service discovery)
// - HTTPS not required

// In production:
// - Authority: https://identity
// - HTTPS required

Common Patterns

Pattern 1: Audit Trail

private static async Task<Created<Item>> CreateItemHandler(
    [FromBody] CreateItemRequest request,
    ClaimsPrincipal user,
    ItemDataProvider dataProvider
)
{
    var audit = user.GetAuditInfo();

    var item = new Item
    {
        Name = request.Name,
        Description = request.Description,
        CreatedBy = audit.UserId,
        CreatedAt = audit.Timestamp,
        TenantId = audit.TenantId
    };

    await dataProvider.CreateItem(item);
    return TypedResults.Created($"/items/{item.Id}", item);
}

Pattern 2: Multi-Tenant Data Access

public class ItemDataProvider(CatalogDb db)
{
    public async Task<List<Item>> GetAllItems(ClaimsPrincipal user)
    {
        // Automatically filters by user's tenant
        return await db.Items
            .FilterByTenant(user)
            .OrderBy(i => i.Name)
            .ToListAsync();
    }

    public async Task<Item?> GetItemById(long id, ClaimsPrincipal user)
    {
        // Ensures user can only access items in their tenant
        return await db.Items
            .FilterByTenant(user)
            .FirstOrDefaultAsync(i => i.Id == id);
    }
}

Pattern 3: Role-Based Logic

private static async Task<Results<Ok<ItemList>, Forbidden>> GetAllItemsHandler(
    ClaimsPrincipal user,
    ItemDataProvider dataProvider
)
{
    // Regular users see only active items
    // Admins see everything
    var items = user.IsAdmin()
        ? await dataProvider.GetAllItemsIncludingInactive(user)
        : await dataProvider.GetActiveItems(user);

    return TypedResults.Ok(items);
}

Pattern 4: Conditional Authorization

public static void MapItemEndpoints(this IEndpointRouteBuilder endpoints)
{
    var items = endpoints.MapGroup("/items").WithTags("Items");

    // Public endpoint
    items.MapGet("/public", GetPublicItemsHandler)
        .AllowAnonymous();

    // Authenticated users
    items.MapGet("/", GetAllItemsHandler)
        .RequireAuthorization();

    // Role-based
    items.MapDelete("/{id}", DeleteItemHandler)
        .RequireAuthorization("CanDeleteItems");

    // Custom policy
    items.MapPost("/admin/bulk-import", BulkImportHandler)
        .RequireAuthorization("SystemAdministration");
}

Testing with Scalar UI

1. Start Your Service

dotnet run --project YourComponent/YourComponent.csproj

2. Get Access Token from Identity Service

Navigate to: https://localhost:40443/scalar

POST /auth/login:

{
  "username": "admin",
  "password": "password",
  "profile_name": "Default"
}

Response:

{
  "access_token": "eyJhbGci...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_at": "2025-01-07T12:00:00Z",
  "user_id": 1,
  "username": "admin"
}

3. Use Token in Your Service

In Scalar UI for your service (https://localhost:43443/scalar):

  1. Click "Authorize"
  2. Select "Bearer"
  3. Paste the access_token
  4. Click "Authorize"

All subsequent requests will include the token automatically!


Troubleshooting

Issue: 401 Unauthorized

Check:

  • Is authentication configured? (builder.AddAcsisAuthentication())
  • Is token expired? (check expires_at in login response)
  • Is token included in request? (check Authorization header)

Debug:

builder.AddAcsisAuthentication(); // Logs auth failures in development

Issue: 403 Forbidden

Check:

  • Does user have required role?
  • Is authorization policy configured correctly?

Solution:

// Check user's role in database
var adminRole = user.FindFirst("admin_role")?.Value;
Console.WriteLine($"User admin role: {adminRole}");

Issue: Tenant filtering not working

Check:

  • Does entity implement ITenantScoped?
  • Does user have a tenant_id claim?

Solution:

var tenantId = user.GetTenantId();
if (!tenantId.HasValue)
{
    Console.WriteLine("Warning: User has no tenant ID");
}

What's Included

One-line setup - builder.AddAcsisAuthentication()
Auto middleware - UseAuthentication() and UseAuthorization() called automatically
Helper extensions - GetUserId(), GetTenantId(), GetUsername(), IsAdmin(), GetAuditInfo()
Query extensions - FilterByTenant() for automatic multi-tenant filtering
Pre-configured policies - Common authorization policies ready to use
Service discovery - Automatic identity service discovery via Aspire
Debug logging - Token validation logging in development


Summary

Before ServiceDefaults Extensions

  • 20+ lines of boilerplate per component
  • Manual middleware registration
  • Manual claim extraction (ugly!)
  • No standardization across components

After ServiceDefaults Extensions

  • 1 line to add authentication
  • 0 lines for middleware (automatic)
  • Clean helpers for all common operations
  • Consistent across all Acsis components

Total effort per component: Add 1 line, protect endpoints, done! 🎉